30. Testy jednostkowe w backendzie

Wyzwania:

  • poznasz task runner Mocha oraz bibliotekę asercji Chai,
  • nauczysz się jak testować modele Mongoose,
  • dowiesz się, w jaki sposób zautomatyzować testowanie endpointów.

Poprzedni moduł zakończyliśmy wnioskiem, że im większy staje się nasz kod, tym mniej pewni jesteśmy jego działania. Dokładnie taki sam problem mieliśmy już po stronie klienta. Dopóki pisaliśmy proste i małe aplikacje, często wystarczały nam dwa lub trzy testy przeprowadzane "ręcznie". Wchodziliśmy do podglądu aplikacji i sprawdzaliśmy, czy wszystko gra, testując najpierw proste działania, a potem ekstremalne sytuacje – na przykład takie, w których celowo wpisywaliśmy niepoprawne dane do formularza. Metoda ta była prosta, ale skuteczna, bo dawała pewność co do działania aplikacji. Kiedy jednak kod się rozrastał i powiększała się liczba funkcjonalności, takiej pewności brakowało, zwłaszcza że mała zmiana w jednym miejscu mogła wpływać na działanie innego elementu. Wtedy z pomocą przyszły nam testy automatyczne i narzędzie o nazwie Jest. Tak samo będzie z testami w backendzie.

Gdy aplikacje były proste, do sprawdzenia poprawności ich działania wystarczyły Postman i MongoDB Compass. Kiedy jednak kod się rozrasta, sytuacja się zmienia, bo ciągłe sięganie do narzędzi, po każdej większej zmianie, staje się czasochłonne, a nawet irytujące. Dlatego także i tutaj korzystanie z testów automatycznych jest po prostu koniecznością.

Choć sama idea i składnia jest bardzo podobna (task runnera Mocha z paczką asercji Chai używa się prawie identycznie jak Jesta), to trochę inny będzie cel testów.

Przede wszystkim u nas serwer najczęściej pełni tylko rolę pośrednika w kontakcie z innym serwerem (serwer API) lub między klientami (WebSocket). Nawet w naszym większym przykładzie z witryną festiwalu, endpointy miały za zadanie tylko kontaktować się z bazą danych i zwracać jakiś rezultat klientowi. Nie mamy tutaj zatem ogromu funkcji i relacji, jak to było po stronie klienta. Tym samym raczej rzadko będziesz mieć okazję tworzyć testy samych pojedynczych funkcji.

Zamiast tego, kiedy mówimy o testowaniu backendu, mamy raczej na myśli weryfikowanie modeli Mongoose, kontrolę operacji CRUD oraz sprawdzanie działania endpointów. Ewentualnie jeśli jest taka potrzeba, możemy testować również działanie WebSocketów, a więc czy serwer dobrze reaguje na zdarzenia i poprawnie je emituje. Niemniej jednak testowanie WebSocketów nie będzie tematem tego modułu.

Na dobrą sprawę, będziemy więc oczekiwać od tych testów dokładnie tego, co robiliśmy dotychczas sami, tylko że teraz będzie się to odbywało automatycznie.

30.1. Pierwsze testy

Czas przygotować CV, LinkedIn i portfolio!

Nadszedł czas na rozpoczęcie prac nad Twoim CV, LinkedIn i portfolio. Aby przygotować je jak najlepiej, wejdź na stronę kontaktu z Doradcą i wybierz swojego Doradcę HR oraz spotkanie (CV/LinkedIn - 30 min). Pamiętaj, aby podać swoje imię i nazwisko podczas rezerwacji!

Poprzednie moduły zaczynaliśmy od dawki teorii. Czasem było to dłuższe wprowadzenie, innym razem krótsze, ale zawsze tłumaczyliśmy, co tak naprawdę będzie tematem naszej pracy. W tej chwili jednak od razu przejdziemy do zadań praktycznych.

Dlaczego? Idea testów nie jest już dla Ciebie niczym nowym, wiesz, po co są nam potrzebne, kiedy warto z nich korzystać i na jakie kategorie je dzielimy. Jeśli uważasz, że słabo pamiętasz tamten materiał, po prostu przeglądnij go jeszcze raz na spokojnie. Prawdopodobnie nie będzie to jednak konieczne. Nawet jeśli wydaje Ci się, że pamięć Cię zawodzi, to pisanie testów jest na tyle intuicyjne, że po kilku przykładach z niniejszego modułu, poczujesz się znowu jak u siebie w domu.

Nie musimy też wprowadzać Cię jakoś szczególnie w nowe narzędzia (Mocha i Chai). Tak naprawdę korzystanie z nich będzie wręcz identyczne jak z pakietu Jest. Oczywiście tym razem testujemy trochę inne rzeczy, więc mogą pojawić się drobne różnice (a raczej nowości), ale będziemy je tłumaczyć na bieżąco.

Zatem, do dzieła!

Pierwsze kroki

Na początku stwierdziliśmy, że testy jednostkowe samych funkcji nie są zbyt często stosowane w backendzie, zwłaszcza jeśli większość logiki jest przechowywana po stronie aplikacji klienckiej (tak było też najczęściej u nas). Mimo to właśnie od testów tego typu zaczniemy. Zrobimy to z dwóch powodów – po pierwsze, najprawdopodobniej prędzej czy później będziesz mieć potrzebę, aby taki kod testować, a po drugie, to coś, co już robiliśmy po stronie klienta, więc powinno Ci być łatwiej odnaleźć się w temacie i przypomnieć sobie podstawowe idee.

Nasz przykład jest bardzo prosty. Otrzymamy gotowy zaczątek projektu serwera API, który będzie w stanie się uruchamiać, choć nie obsłuży jeszcze żadnych endpointów. Nie jest to dla nas problemem, bo zajmiemy się wyłącznie plikiem cutText.js, a dokładnie gotową funkcją, która jest w nim przechowywana. Naszym zadaniem będzie jej przetestowanie.

Całość możesz pobrać pod poniższym linkiem.

Zacznij od rozpakowania paczki i zainicjowania projektu (yarn init).

Struktura nie jest skomplikowana. Plik server.js służy do inicjacji serwera, a w folderze utils przechowywane są funkcje pomocnicze, których jeszcze nie używamy, ale wkrótce będziemy i dlatego musimy przygotować do nich testy. W samym katalogu utils są dwa pliki z funkcjami.

Otwórz cutText.js i przeanalizuj działanie tego kodu:

exports.default = (content, maxLength) => {
  if(content.length < 1) return 'Error';
  if(content.length <= maxLength) return content;
  return content.substr(0, content.lastIndexOf(' ', maxLength)) + '...';
};

Idea jest prosta. Funkcja przyjmuje dwa argumenty – content i maxLength. Pierwszy po prostu przekazuje treść do skrócenia, a drugi informuje, jak długi ma być tekst już po przycięciu.

Samo działanie też nie jest skomplikowane. Jeśli treści nie ma, funkcja zwróci Error. W przypadku, gdy maxLength, a więc założony nowy rozmiar, jest większy lub równy aktualnej długości tekstu, pozostawiamy go bez zmian, nie ma wtedy bowiem czego przycinać. W innej sytuacji zwracany jest odpowiednio skrócony tekst. Co istotne – funkcja nie przycina treści na ślepo. Jeśli koniec tekstu byłby wewnątrz jakiegoś słowa, to utniemy całość trochę wcześniej, dokładnie przed tym niepełnym słowem, a więc np. cutText('Up to the hill!', 8) nie zwróci Up to th, lecz Up to. Na końcu przyciętego tekstu dodatkowo dodawany jest jeszcze wielokropek (...).

Konkretny kod nie jest dla nas najważniejszy, wiemy jednak czego mamy od tej funkcji oczekiwać. Przed samymi testami, pozostał jeszcze tylko jeden krok, a mianowicie musimy pobrać task runner oraz bibliotekę asercji, które nam pomogą.

Tym razem skorzystamy z zestawu Mocha i Chai. Zapytasz – a czy nie można by użyć Jesta? Cóż, w teorii tak. Pamiętaj jednak, że to framework stworzony specjalnie z myślą o testowaniu frontendu, przez co ma pewne braki i ograniczenia w innych obszarach. Mocha i Chai są narzędziami przygotowanymi zarówno do pracy po stronie klienta, jak i serwera.

Przejdźmy więc do instalacji:

yarn add mocha@6.2.1 chai@4.2.0

Mocha + Chai vs Jest

Przy okazji wyjaśnijmy jedną rzecz. Dlaczego w przypadku Jesta potrzebowaliśmy tylko jednej paczki, a tutaj konieczne są aż dwie?

Mocha jest wyłącznie task runnerem. Oznacza to, że potrafi obsługiwać skrypty z testami na tyle, żeby je uruchomić oraz rozumieć składnię ich opisów. Nie zawiera jednak funkcji asercji do łatwego sprawdzania poprawności tego, co otrzymaliśmy w received, względem tego, czego oczekiwaliśmy (expected). Nie mamy tu więc zestawu funkcji typu .toBe, czy exists, które możesz pamiętać z Jesta. Wszystko musielibyśmy sprawdzać za pomocą czystego JS-a lub ewentualnie przy użyciu biblioteki assert wbudowanej w Node.js, która cóż... nie jest specjalnie przyjazna w pracy.

Dlatego też sięgając po Mochę, od razu instalujemy bibliotekę asercji. Może to być should.js, expect.js, czy właśnie chai.js. Każda będzie działać w gruncie rzeczy tak samo. My wybraliśmy tę trzecią z racji jej dużej popularności.

Zatem dlaczego Jest nie potrzebuje podobnego zabiegu? Ponieważ Jest to nie tylko task runner, ale również biblioteka asercji. A żeby być bardziej precyzyjnym – to task runner, który ma domyślnie wbudowaną własną bibliotekę asercji, przez co jest samowystarczalny.

Od razu możemy przygotować task w package.json, który będzie uruchamiać nasze testy. Pamiętasz, jak wyglądało to w Jest? Bardzo prosto – stosowaliśmy komendę jest. Tutaj będzie podobnie, wystarczy użycie mocha.

"test": "mocha"

Warto jednak skorzystać jeszcze z opcji --watch, która zapewni nam ponowne przetestowanie kodu w przypadku jakichkolwiek zmian.

"test": "mocha --watch"

Pierwszy testy

Przejdźmy już do samych testów. Zacznijmy od utworzenia pliku, w którym będziemy je przechowywać.

Domyślnie Mocha szuka plików z testami w folderze test w głównym katalogu. Taka opcja sprawdza się, gdy piszemy np. testy integracyjne albo testy samego API. Nie są one kojarzone z jednym plikiem czy funkcjonalnością, zatem wydzielenie osobnego folderu wydaje się bardzo dobrym pomysłem.

Sytuacja wygląda trochę inaczej w przypadku testów jednostkowych. Tutaj lepiej trzymać pliki z testami bliżej plików z funkcjami, których się tyczą.

Na szczęście Mocha pozwala łatwo dodać taką funkcjonalność. Wystarczy, że zmodyfikujemy nasz task test.

"test": "mocha --watch \"./{,!(node_modules)/**/}*.test.js\""

Taki skrypt powinien wyszukiwać wszystkie pliki w naszym katalogu, których rozszerzenie to .test.js – nieważne, czy są w folderze test, czy może utils. Przy okazji jednak ustalamy, że nie chcemy przeszukiwać katalogu node_modules.

Pierwszy problem mamy więc z głowy. Przejdźmy teraz do katalogu utils. Utwórz w nim nowy folder test, a w nim plik – cutText.test.js, w którym będziemy zapisywać testy. Oczywiście to tylko nasza konwencja. Równie dobrze moglibyśmy trzymać go obok plików z samymi funkcjami, jednak w przypadku większej ich ilości, zrobiłby się spory bałagan.

Założenia

Przejdź teraz do tego pliku. Czas zastanowić się jakie testy będą nam potrzebne. Biorąc pod uwagę założenia działania tej funkcji, lista wymagań mogłaby być następująca:

  • Jeśli argument content nie jest stringiem, to funkcja powinna zwrócić Error.
  • Jeśli długość tekstu w content jest równa 0, to funkcja powinna zwrócić Error.
  • Jeśli maxLength nie jest liczbą, to funkcja powinna zwrócić Error.
  • Jeśli maxLength jest mniejsze od zera lub równe zero, to funkcja powinna zwrócić Error.
  • Jeśli content i maxLength są poprawne i maxLength jest większe lub równe content.length, to funkcja powinna zwrócić content bez żadnych zmian.
  • Jeśli content i maxLength są poprawne i maxLength jest mniejsze od content.length, to funkcja powinna zwrócić tekst przycięty do maxLength ilości znaków. Przy czym przycięcie nie może nastąpić w środku słowa. Jeśli miałoby tak być, to funkcja powinna zwrócić tekst przycięty przed tym niepełnym słowem.
  • Jeśli content i maxLength są poprawne i maxLength jest mniejsze od content.length, to funkcja powinna zwrócić na końcu tekstu ....

Lista jest stosunkowo długa jak na tak małą funkcję, ale z drugiej strony musi wyczerpać temat i dać nam pewność, że kod nigdy nas nie zawiedzie. Same testy nie powinny być za to bardzo skomplikowane. Zauważ, że będziemy sprawdzać dość proste przypadki. Dzięki temu masz szansę, aby w przyjazny i szybki sposób przypomnieć sobie, jak testowanie w ogóle wygląda.

Bierzemy się do pracy

Na początku pliku cutText.test.js zaimportuj funkcję cutText:

const cutText = require('../cutText.js');

Musimy to zrobić, jeśli chcemy ją sprawdzić.

Co dalej? Jak zapewne pamiętasz, w Jest stworzylibyśmy blok describe, a następnie zapisywalibyśmy pojedyncze testy w it. Mamy dla Ciebie dobrą wiadomość – tutaj wygląda to dokładnie tak samo.

Zacznijmy więc od dodania bloku describe, a następnie pierwszego it dla testu numer jeden.

describe('CutText', () => {

  it('should return an error if "content" arg is not a string', () => {

  });

});

Wygląda znajomo, prawda?

Teraz przejdźmy do testu.

it('should return an error if "content" arg is not a string', () => {
  expect(cutText(undefined, 20)).to.equal('Error');
  expect(cutText(12, 20)).to.equal('Error');
  expect(cutText({}, 20)).to.equal('Error');
  expect(cutText([], 20)).to.equal('Error');
  expect(cutText(function() {}, 20)).to.equal('Error');
});

Jak widzisz, same asercje też niewiele się różnią. Nazewnictwo metod jest trochę inne, ale równie intuicyjne. Mimo innego task runnera możesz poczuć się więc jak u siebie w domu.

Sam test nie jest skomplikowany. Sprawdza po prostu funkcję cutText w sytuacjach, kiedy typ argumentu content będzie inny niż string. Oprócz standardowych typów, jak number czy object, przy okazji kontrolujemy też undefined. Tym samym upewniamy się, czy funkcja zwróci Error również wtedy, kiedy argument będzie zwyczajnie pusty.

Co do drugiego argumentu, który u nas zawsze jest równy 20, komentarz wydaje się zbędny. Przekazujemy po prostu pierwszą lepszą poprawną wartość tego argumentu, aby zawsze był prawidłowy. Jego brak mógłby sfałszować wyniki testu. Pomyśl, co by było, gdyby np. funkcję napisano tak, że sprawdzałaby tylko drugi argument i przy jego braku zwracała Error, a pierwszego już nie. Test cutText(20) dałby nam wtedy "niby" poprawny tekst Error, mimo tego, że w takiej sytuacji byłoby to efektem nie tyle dobrej reakcji na błędny content, co raczej na brak drugiego argumentu. Nie chcemy takich sytuacji.

Uruchom teraz task test.

image

Oj, nie tego się spodziewaliśmy. Co ciekawe jednak błąd wskazuje nie tyle problem z przejściem testu przez cutText, ale z samą funkcją expect. Skąd ten komunikat?

Wspominaliśmy, że Jest ma wbudowaną bibliotekę asercji, ale w przypadku Mochy jest inaczej. Widzimy, że potrafiła poradzić sobie z describe czy it, ale funkcje asercji to już nie jej bajka. Zdawaliśmy sobie z tego sprawę i właśnie po to pobraliśmy również Chai, wystarczy więc ją poprawnie zaimportować.

const expect = require('chai').expect;

Teraz test powinien zadziałać już poprawnie.

image

Task faktycznie działa już dobrze, test również, ale jak widać, funkcja nie jest w stanie go pozytywnie przejść. Okazuje się, że nie tylko nie zwraca nam komunikatu Error w każdym ze sprawdzanych przypadków, ale do tego jeszcze zwyczajnie się "wysypuje". Jak widać, decyzja o przetestowaniu tej funkcji była bardzo dobra.

Twoim zadaniem jest więc naprawienie teraz tej funkcji, tak aby spełniała wymagania testu.

if(typeof content !== 'string') return 'Error';

Wystarczy dodać warunek, który w przypadku wykrycia, że content jest czymś innym niż string, zwróci Error.

Po zmianach ponownie sprawdź konsolę. Jeśli wszystko poszło dobrze, to naszym oczom ukaże się taki widok:

image

Dokładnie na to czekaliśmy!

Pierwszy test jest już gotowy, a funkcja pozytywnie go przechodzi. W teorii robimy coś nowego, ale musisz przyznać, że można mieć małe déjà vu. Wszystko wygląda prawie tak samo, jak testy przeprowadzane w module o frontendzie.

Kolejne testy

Test 2

Skoro tak dobrze poszło nam z pierwszym, możemy iść dalej. Test numer dwa brzmi tak: jeśli długość tekstu content jest równa 0, to funkcja powinna zwrócić Error.

Tutaj skrypt będzie o wiele krótszy:

it('should return an error if "content" arg length is 0', () => {
  expect(cutText('', 20)).to.equal('Error');
});

Funkcja powinna przejść go pozytywnie, nawet bez żadnych zmian.

Test 3

Przypomnijmy założenie: jeśli maxLength nie jest liczbą, to funkcja powinna zwrócić Error.

Test jest podobny do tego, od którego zaczęliśmy, z tym że teraz to pierwszy parametr będzie miał ustawioną poprawną wartość.

it('should return an error if "maxLength" arg is not a number', () => {
  expect(cutText('Lorem Ipsum', undefined)).to.equal('Error');
  expect(cutText('Lorem Ipsum', 'abc')).to.equal('Error');
  expect(cutText('Lorem Ipsum', {})).to.equal('Error');
  expect(cutText('Lorem Ipsum', [])).to.equal('Error');
  expect(cutText('Lorem Ipsum', function() {})).to.equal('Error');
});

Tym razem jednak funkcja znowu nie podoła.

image

Ponownie musimy ją odpowiednio zmodyfikować. Tym razem poradzisz sobie bez naszej pomocy, prawda? ;)

Test 4

Założenie brzmi: jeśli maxLength jest mniejsze od zera lub równe zero, to funkcja powinna zwrócić Error.

Test ponownie będzie krótki...

it('should return an error if "maxLength" is lower or equal 0', () => {
  expect(cutText('Lorem Ipsum', 0)).to.equal('Error');
  expect(cutText('Lorem Ipsum', -6)).to.equal('Error');
});

Funkcja niestety go nie przejdzie.

W tym miejscu warto już zwrócić uwagę, jak bardzo testy potrafią nam pomóc. Kiedy po raz pierwszy analizowaliśmy funkcję cutText, wydawało się, że jest ona napisana w naprawdę sensowny sposób. Tymczasem okazuje się, że ma wiele dziur i bardzo łatwo można ją doprowadzić do stanu "wysypania się". Na szczęście, dzięki testom, powoli to zmieniamy.

Test 5

Jeśli content i maxLength są poprawne i maxLength jest większe lub równe content.length, to funkcja powinna zwrócić content bez żadnych zmian.

Sprawdzimy tutaj dwa przypadki: kiedy maxLength jest większy niż długość content i kiedy obie wartości są równe.

it('should return "content" without changes if proper args', () => {
  expect(cutText('Lorem Ipsum', 40)).to.equal('Lorem Ipsum');
  expect(cutText('Lorem Ipsum', 11)).to.equal('Lorem Ipsum');
});

Akurat tutaj funkcja zadziała poprawnie.

Ostatnie testy

Zostały nam jeszcze tylko dwa testy:

  • Jeśli content i maxLength są poprawne i maxLength jest mniejsze od content.length, to funkcja powinna zwrócić tekst przycięty do maxLength ilości znaków. Przy czym przycięcie nie może nastąpić w środku słowa. Jeśli miałoby tak być, to funkcja powinna zwrócić tekst przycięty przed tym niepełnym słowem.
  • Jeśli content i maxLength są poprawne i maxLength jest mniejsze od content.length, to funkcja powinna zwrócić na końcu tekstu ....

Połączmy je w jeden:

it('should return good cut "content" if proper args', () => {
  expect(cutText('Lorem Ipsum dolor sit amet', 14)).to.equal('Lorem Ipsum...');
  expect(cutText('Lorem Ipsum dolor sit amet', 5)).to.equal('Lorem...');
  expect(cutText('Lorem Ipsum dolor sit amet', 17)).to.equal('Lorem Ipsum dolor...');
});

Sprawdzamy tu dwie możliwości – kiedy przycinamy tekst pomiędzy i w środku słowa. W tej drugiej sytuacji cutText powinna zwrócić tekst przycięty, ale jeszcze z całym ostatnim wyrazem.

Funkcja przejdzie ten test pozytywnie.

image

Podsumowanie

Jak widzisz, pomimo nowego materiału, nie było tu zbyt wielu nowości. Testy w backendzie są nieodzowne, a skoro ich pisanie jest równie intuicyjne, jak po stronie klienta, grzechem byłoby się w tym temacie nie rozwinąć.

Zadanie: więcej praktyki

Czas na małą dawkę praktyki. Twoim zadaniem będzie napisanie testów do funkcji formatFullname, którą znajdziesz w utils/formatFullname.js.

Wygląda ona następująco:

exports.default = (fullName) => {
  const [ firstName, lastName ] = fullName.split(' ');
  if(!firstName || !lastName) return false;
  return firstName[0].toUpperCase + firstName.slice(1).toLowerCase() + lastName[0].toUpperCase + lastName.slice(1).toLowerCase();
};

Kod powinien działać dość prosto. W założeniu funkcja ma przyjmować jeden argument (fullName), który musi być stringiem w formacie <firstname> <lastname> (np. John Doe). Funkcja nie powinna zakładać z góry, że wielkość liter jest poprawna, a zatem fullName może wyglądać np. tak jOHn dOE.

Zadaniem skryptu jest poprawienie właśnie takich błędnych przypadków – nie ważne, jaka będzie wielkość poszczególnych otrzymanych liter, zwrócony ma zostać właściwy zapis. Zatem amanda doe zostanie zmienione na Amanda Doe, JOHN DOE lub JOHN doE na John Doe itd.

Dodatkowo:

  • jeśli nie podano nic, to funkcja powinna zwrócić tekst Error,
  • jeśli podano coś innego niż string, to funkcja powinna zwrócić tekst Error,
  • jeśli format otrzymanych danych jest inny niż <firstname> <lastname>, czyli np. podano coś więcej po kolejnej spacji (John Doe Test) albo podano tylko imię lub tylko nazwisko (np. tylko John), to funkcja również powinna zwrócić Error.

Cały proces będzie oczywiście taki sam jak w przypadku cutText.

  1. Zacznij od stworzenia pliku formatFullname.test.js w folderze utils/test.
  2. Następnie zastanów się, co warto przetestować, oczywiście biorąc pod uwagę listę założeń.
  3. W kolejnym kroku napisz po kolei każdy z testów. Pamiętaj, że funkcja nie zawsze przejdzie je poprawnie. W takich wypadkach odpowiednio ją na bieżąco modyfikuj.
  4. Gdy funkcja zaliczy wszystkie założone testy, zadanie będzie gotowe.

Powodzenia!

30.2. Testowanie modeli Mongoose

Pierwszy submoduł nie przyniósł wielu nowości. Używaliśmy innego task runnera, ale jego działanie było wręcz identyczne, jak w znanych nam już narzędziach.

Backend to jednak także funkcjonalności, których we frontendzie nie uświadczymy, np. obsługa endpointów czy też współpraca z bazą danych. Testowaniem właśnie tych części naszej aplikacji będziemy się od teraz zajmować.

Jako pierwsze weźmiemy na warsztat modele Mongoose i sprawdzimy, czy schematy struktury danych, które w nich wykorzystujemy, faktycznie dobrze walidują wprowadzane informacje. Może Ci się wydawać, że takie testy są zbędne, bo same schematy są najczęściej dość małe i błędy nie zdarzają się zbyt często. Wbrew pozorom, taka weryfikacja poprawnego działania może być całkiem przydatna.

Po pierwsze, testy są w stanie sprawdzić w praktyce, jak model poradzi sobie z próbą dodawania różnych danych – nie tylko poprawnych, ale również wadliwych. Dzięki temu bardzo łatwo będziemy mogli zauważyć ewentualne luki w naszym schemacie struktury danych. Na przykład może okazać się, że zapomnieliśmy o dodaniu dla któregoś z atrybutów opcji required, przez co model pozwalałby na wprowadzanie niepełnych danych. Łatwo moglibyśmy też wykryć błędne przygotowanie referencji. Mamy więc pierwszą zaletę, jesteśmy w stanie szybko naprawić ewentualne niedoskonałości schematu struktury danych.

Po drugie, w czasie pracy nad aplikacją, bardzo często modyfikujemy nasze schematy, a wtedy łatwo o ich popsucie. Ma to miejsce zwłaszcza, jeśli przed przystąpieniem do prac nie zaplanujemy odpowiednio struktury bazy danych. Testy pozwalają nam na bieżąco kontrolować poprawność schematów.

Nie tylko schematy...

Na razie mówimy tylko o sprawdzeniu poprawności walidacji z użyciem schematów, więc to bardziej testy samych schematów niż modeli.

Niemniej jednak testowanie modeli powinno być czymś więcej. Warto sprawdzać np. czy wszystkie operacje CRUD będą działać poprawnie. Jest bowiem możliwe, że przy wybraniu złych uprawnień dla użytkownika, dana operacja (np. deleteOne czy updateOne), zamiast zmodyfikować kolekcję, zwróci błąd. Warto też wiedzieć, że oprócz wbudowanych metod, do modeli możemy dodawać własne. W takiej sytuacji nasze testy będą jeszcze bardziej rozbudowane.

W tym submodule nie będziemy jednak poruszać kwestii testowania metod. Na razie zajmiemy się tylko sprawdzeniem poprawności walidacji z naszymi schematami.

Zanim przejdziemy dalej...

Sprawdzimy poprawność działania naszej aplikacji z poprzedniego modułu, czyli serwera API, który współpracował z bazą danych companyDB. Odnajdź ten projekt.

Nie używaliśmy w nim jeszcze Mochy ani Chai, pobierz je więc teraz:

yarn add mocha@6.2.1 chai@4.2.0

Dodaj również nowy task do package.json:

"test": "mocha --watch \"./**/*.test.js\""

Czas zastanowić się, gdzie będziemy przechowywać nasze testy dla modeli. Moglibyśmy np. w folderze test w głównym katalogu projektu, albo w models/test, trochę bliżej samych modeli. Nie narzucamy żadnego pomysłu, możesz wykorzystać dowolny z nich. W materiale będziemy jednak trzymać się tej drugiej idei, bo zaproponowaliśmy ją już przy poprzednim zadaniu.

Wejdź więc do katalogu models i utwórz nowy folder – test.

Testujemy pierwszy model

Zaczniemy od testów do najmniejszego z naszych modeli – Department. Utwórz więc teraz nowy plik w folderze test i nazwij go department.test.js.

Zanim przejdziemy dalej, spójrzmy na ten model, a przede wszystkim na schemat struktury danych:

const mongoose = require('mongoose');

const departmentSchema = new mongoose.Schema({
  name: { type: String, required: true }
});

module.exports = mongoose.model('Department', departmentSchema);

Tak naprawdę mamy tutaj tylko jeden atrybut, który warto wziąć pod uwagę przy testach, bo _id jest nadawany przez MongoDB automatycznie, możemy więc założyć, że będzie poprawny. Chodzi oczywiście o name.

Co dokładnie warto sprawdzić? Przede wszystkim, czy Mongoose pozwoli na dodawanie nowego dokumentu bez tego atrybutu albo z błędną jego wartością. Warto też zweryfikować, czy jeśli wpiszemy name poprawnie, to nie dostaniemy żadnego błędu. I... tyle.

Przejdźmy więc do rzeczy. Dodaj do pliku department.test.js nowy blok describe oraz it. Na razie zajmiemy się pierwszym testem. Będziemy sprawdzać, czy Mongoose zwróci nam błąd, jeśli spróbujemy dodać nowy dokument bez atrybutu name.

describe('Department', () => {

  it('should throw an error if no "name" arg', () => {

  });

});

Żeby napisać ten test, jak i kolejne, potrzebujemy dostępu do modelu Department. Zaimportuj go na samej górze pliku.

const Department = require('../department.model.js');

Naturalnie ponownie skorzystamy z metody expect z pakietu chai.

const expect = require('chai').expect;

Teraz wróćmy już do naszego testu. Jak możemy go napisać? Sytuacją, którą zasymulujemy, będzie po prostu dodanie nowego dokumentu do kolekcji:

it('should throw an error if no "name" arg', () => {
  const dep = new Department({}); // create new Department, but don't set `name` attr value

  dep.validate(err => {
    expect(err.errors.name).to.exist;
  });

});

Na samym początku staramy się tworzyć nowy dokument na bazie modelu Department.

const dep = new Department({}); // create new Department, but don't set `name` attr value

Oczywiście celowo nie ustalamy wartości dla name, by sprawdzić, czy Mongoose zwróci nam w takiej sytuacji błąd.

Ciekawszy będzie kod poniżej.

dep.validate(err => {
  expect(err.errors.name).to.exist;
});

Do zapisania dokumentu w kolekcji, zawsze używaliśmy funkcji save, a tym razem tego nie robimy. Dlaczego?

Zauważ, że zaimportowaliśmy model, ale w żadnym miejscu nie utworzyliśmy połączenia z bazą danych. Tym samym użycie save nie ma sensu – nie moglibyśmy zapisać danych do kolekcji, skoro nie mamy zainicjowanego połączenia z bazą i otrzymywalibyśmy fałszywy błąd. Test, zamiast sygnalizować niewłaściwą walidację, wykazywałby problem, ale z powodu braku połączenia z bazą danych.

Zastosowaliśmy więc inne rozwiązanie. Przed dodaniem danych, save korzysta z metody validate, dla której brak połączenia z bazą nie jest problemem. Ta funkcja ma na celu zweryfikowanie danych zgodnie ze schematem, więc nie potrzebujemy już samego save.

Jej działanie jest dość proste – sprawdza po kolei każdy atrybut i jeśli zauważy jakiś błąd, zapisuje go w obiekcie errors, pod nazwą tego atrybutu. Po procesie walidacji całość jest po prostu zwracana w funkcji callback. Co istotne dla nas, jeśli wiemy, że w errors są zapisywane informacje tylko dla błędnych atrybutów, to jesteśmy w stanie łatwo ustalić, który jest wadliwy. Na przykład, jeśli errors.name nie istnieje, możemy założyć, że sprawdzono go i nie doszukano się błędów. Jeśli jednak występuje, to wiemy już, że coś było nie tak. W errors są oczywiście przechowywane dokładne komunikaty o błędach, ale nam wystarczy sprawdzenie, czy w ogóle się pojawiły.

Stąd też nasz łatwy test:

expect(err.errors.name).to.exist;

Sprawdzamy po prostu, czy Mongoose zapisał przy walidacji, że pod name był błąd.

Uruchom teraz nasz task test i zobacz, co pojawi się w konsoli.

image

Test udało się przejść pozytywnie!

Uwaga!

Możesz zauważyć, że w przypadku zmian w pliku z testami i uruchomienia ich automatycznie od nowa przez Mochę, otrzymujemy błąd.

OverwriteModelError: Cannot overwrite `Department` model once compiled.

Jak widzisz, Mocha zwyczajnie nie radzi sobie z użyciem tego samego modelu po raz kolejny. Sytuację da się łatwo naprawić. Wystarczy, że będziemy "czyścić" nasze modele po każdorazowym wykonaniu testów. Możemy do tego użyć hooka after:

after(() => {
  mongoose.models = {};
});

Dzięki temu, odświeżenie testów nie będzie powodowało błędu, mimo ponownej inicjacji modelu. Pamiętaj też o zaimportowaniu mongoose za pomocą require.

Kolejny test

Czas na test numer 2. Warto sprawdzić, jak poradzi sobie nasz model, gdy jako name podamy wartości innego typu niż string. Całość będzie wyglądała analogicznie. Znowu stworzymy dokumenty na bazie modelu Department i będziemy sprawdzać, czy ich walidacja zapisze komunikat o błędzie do errors.name. Tym razem jednak musimy przetestować więcej niż jeden przypadek, warto więc skorzystać z tablicy.

it('should throw an error if "name" is not a string', () => {

  const cases = [{}, []];
  for(let name of cases) {
    const dep = new Department({ name });

    dep.validate(err => {
      expect(err.errors.name).to.exist;
    });

  }

});

Test powinien przejść pomyślnie.

Może Cię zdziwić brak testów dla przykładowej wartości typu Number. Ma to swoje wyjaśnienie w tym, jak Mongoose działa. Jeśli oczekujemy na String, a przekazywana wartość to liczba, Mongoose stara się przekonwertować ją do tekstu, czyli np. 5 zapisze się w bazie jako "5". Tym samym sprawdzenie, czy model wyrzuci błąd dla jakiejś liczby, nie ma sensu, bo wiemy już teraz, że tego nie zrobi. Nie musisz się jednak martwić, liczba zostanie zapisana w bazie jako string.

Walidacja Number

A co jeśli chcielibyśmy, aby wartości liczbowe od razu powodowały błąd? Istnieje możliwość nałożenia odpowiedniego wymagania w samym schemacie (trochę więcej o tym za chwilę), ale nie będziemy tego robić w naszym przykładzie.

Większa wymagania = więcej testów

Warto wiedzieć, że oprócz ustalenia w schematach nazw atrybutów czy ich typów, istnieje jeszcze możliwość nałożenia dokładniejszych wymagań. Możemy ustawić np. jaka jest minimalna (min) lub maksymalna wartość (max) atrybutu liczbowego, albo limity dla długości atrybutów tekstowych (minlength i maxlength). Możemy również dodawać własne funkcje, które służyłyby jeszcze większemu ograniczeniu wybranych atrybutów, na przykład sprawdzałyby, czy nasza wprowadzana wartość nie jest czasem liczbą (patrz: wcześniejszy test).

W naszym department.model.js moglibyśmy użyć np. minlength i maxlength:

const departmentSchema = new mongoose.Schema({
  name: { type: String, required: true, minlength: 5, maxlength: 20 }
});

Wykonaj taką modyfikację w swoim modelu. Od teraz nazwa działu musi mieć 5 lub więcej znaków, ale nie może przekroczyć 20.

W przypadku takich modeli warto sprawdzić dodatkowe wymagania. Dodamy więc kolejny test. Na szczęście nie będzie to wcale trudne, ponownie wystarczy użycie funkcji validate.

Spróbuj wykonać ten test bez naszej pomocy, wzorując się na wcześniejszych przykładach.

it('should throw an error if "name" is too short or too long', () => {

  const cases = ['Abc', 'abcd', 'Lorem Ipsum, Lorem Ip']; // we test various cases, some of them are too short, some of them are too long
  for(let name of cases) {
    const dep = new Department({ name });

    dep.validate(err => {
      expect(err.errors.name).to.exist;
    });

  }

});

Ostatni test

Czas na ostatni test. Wiemy już, że w przypadku błędnego name albo przy braku tego atrybutu, Mongoose zwróci błąd. Warto upewnić się jeszcze, czy błędu nie będzie, jeśli wszystko wpiszemy dobrze.

Spróbuj stworzyć taki test bez naszej pomocy.

it('should not throw an error if "name" is okay', () => {

  const cases = ['Management', 'Human Resources'];
  for(let name of cases) {
    const dep = new Department({ name });

    dep.validate(err => {
      expect(err).to.not.exist;
    });

  }

});

Naturalnie tym razem nie chcemy, aby jakikolwiek błąd się pojawił, stąd też to.not.exist zamiast to.exist.

Jeśli udało Ci się napisać wszystkie testy poprawnie, powinniśmy zobaczyć w konsoli przyjazny widok:

image

Tym samym testowanie pierwszego modelu możemy uznać za zakończone. Nie było to aż takie trudne, prawda? :)

Podsumowanie

Jak widzisz testowanie schematów Mongoose, mimo tego, że było nowością, nie sprawiło nam wielu problemów, choć okazało się mniej intuicyjne niż testowanie zwykłych funkcji z pierwszego submodułu.

Niemniej jednak wszystkie te zabiegi to trochę za mało, by dać nam wystarczającą pewność co do działania aplikacji. Chcielibyśmy wiedzieć, czy dane przy save faktycznie dodają się do bazy, oraz czy po użyciu delete rzeczywiście ich się pozbywamy. Tym zajmiemy się jednak już w kolejnym submodule.

Zadanie: testujemy kolejny model

Czas na przećwiczenie nowych umiejętności. Testy dla modelu Department napisaliśmy razem, a Twoim zadaniem będzie przygotowanie ich dla Employee.

Wszystkie testy przechowuj w models/test/employee.test.js. Przy ich pisaniu możesz wzorować się na tym, jak sprawdzaliśmy model Department.

W razie problemów skorzystaj również z dokumentacji Chai.js.

Uwaga!

Podczas pisania testów dla Department, zmodyfikowaliśmy lekko nasz schemat, aby sprawdzał również długość name. Zrobiliśmy to tylko po to, aby pokazać Ci, że testy dla takich przypadków będą równie intuicyjne. W modelu Employee nie musisz już jednak wykonywać takich operacji. Schemat struktury danych nie powinien być modyfikowany, a testy niech go sprawdzają w takiej formie, w jakiej jest aktualnie.

30.3. Testowanie operacji CRUD

Testowanie schematu struktur danych to dobra praktyka zapewnia nas bowiem, że Mongoose będzie w stanie dbać o poprawność informacji wprowadzanych do bazy.

Niemniej jednak w żaden sposób nie daje nam to gwarancji, że sama komunikacja działa bez zarzutów. Na przykład może okazać się, że z jakiegoś powodu metoda .save, mimo użycia poprawnych danych, nie doda dokumentu do bazy, albo metoda updateOne niepoprawnie ją zaktualizuje itd. Mogą pojawić się też inne problemy, np. w sytuacji, gdy mamy dwie kolekcje, które są ze sobą jakoś powiązane (wykorzystują ref). Warto wtedy sprawdzić, czy find wraz z populate, faktycznie zwróci poprawne dane.

W tym submodule zajmiemy się właśnie takimi przypadkami i testami działania metod CRUD.

Przygotowania

Wciąż pracujemy na przykładzie z poprzedniego submodułu.

Na początku zastanówmy się, gdzie będziemy przechowywać nowe testy. Od razu do głowy przychodzą dwa pomysły – skoro w folderze models trzymaliśmy testy struktur danych modeli, to może jest to również dobre miejsce do przechowywania testów ich metod? Drugi pomysł byłby kompletnie inny. Będziemy testować operacje CRUD, więc będzie to zestaw całkiem nowych, odrębnych testów, a zatem może warto trzymać je w katalogu głównym, w podfolderze test?

Znowu nie będziemy narzucać żadnego z pomysłów, niemniej jednak dla ułatwienia znajdowania plików (krótsze ścieżki), w niniejszym submodule postawimy na pierwszą ideę.

Zaczniemy od testowania modelu Department. Wejdź teraz do folderu models/test i utwórz tam nowy plik – department.crud.test.js. Oczywiście taka nazwa to tylko nasz wybór, końcówka crud.test.js ma po prostu w założeniu pomagać w łatwej identyfikacji właściwych plików.

Otwórz go teraz i zacznij od zaimportowania modelu Department oraz metody expect z pakietu Chai. Utwórz również blok describe.

const Department = require('../department.model');
const expect = require('chai').expect;

describe('Department', () => {

});

Skoro będziemy korzystać z modelu, to warto przygotować też blok after, w którym będziemy "kasować" model, gdy już go nie potrzebujemy. Jak zapewne pamiętasz z poprzedniego submodułu, jest to konieczne, aby Mocha w trybie --watch mogła obsługiwać go poprawnie.

const Department = require('../department.model');
const expect = require('chai').expect;

describe('Department', () => {

  after(() => {
    mongoose.models = {};
  });

});

Połączenie z bazą danych

Zanim przejdziemy dalej, musimy się na chwilę zatrzymać. W poprzednim submodule nie potrzebowaliśmy połączenia z bazą danych, bo testowaliśmy tylko samo działanie schematów. Tutaj będzie inaczej – chcemy sprawdzać, czy wykorzystanie metod CRUD faktycznie odnosi pożądany skutek.

Jednak, czy aby na pewno chcemy pracować na prawdziwej bazie – dodawać dane, usuwać je i modyfikować w naszych kolekcjach? Niekoniecznie, jeśli z jakiegoś powodu zależy Ci na integralności tych danych. Dobrym wyjściem może być użycie jakiejś paczki, która pozwoli nam na testowanie metod CRUD, bez faktycznej modyfikacji naszej bazy danych.

Na rynku istnieje kilka pakietów, które funkcjonują w ten sposób, a jeden z nich to mongodb-memory-server. Jego działanie jest bardzo sprytne – pozwala na utworzenie tymczasowej bazy danych i użycie jej przy połączeniu. Co istotne, jest ona kreowana w dokładnie taki sam sposób jak "normalne". Wszystkie operacje, które będziemy wykonywać na tej kopii, będą więc działały dokładnie tak samo, jak na oryginale. Różnica jest tylko taka, że jej zawartość będzie przechowywana w pamięci systemu i zostanie skasowana po zakończeniu działania testów. Tym samym otrzymujemy możliwość sprawdzenia działania odpowiednich metod w "warunkach bojowych", bez nienaruszania oryginalnych danych. Brzmi ciekawie, prawda?

Instalacja paczki

Zacznij od zainstalowania tej paczki:

yarn add mongodb-memory-server

Przygotowanie połączenia

Czas na przygotowanie połączenia.

Zacznij od zaimportowania funkcji do tworzenia testowej bazy danych:

const MongoMemoryServer = require('mongodb-memory-server').MongoMemoryServer;

Przyda się też Mongoose:

const mongoose = require('mongoose');

Potrzebujemy jej, bo w końcu tworzymy połączenie. Owszem, baza będzie testowa, ale samo połączenie prawdziwe.

Następnie dodaj do bloku describe, hook before.

before(async () => {

  try {
    const fakeDB = new MongoMemoryServer();

    const uri = await fakeDB.getConnectionString();

    mongoose.connect(uri, { useNewUrlParser: true, useUnifiedTopology: true });

  } catch(err) {
    console.log(err);
  }

});

Jak widzisz, kod jest dość krótki.

const fakeDB = new MongoMemoryServer();

Na początku tworzymy nową testową bazę danych. Warto jednak wiedzieć, że mamy możliwość założenia nawet kilku takich "oszukanych" baz na raz.

const uri = await fakeDB.getConnectionString();

Następnie pobieramy adres tej bazy. Domyślnie paczka tworzy ją pod adresem 127.0.0.1 i łączy się za pomocą pierwszego lepszego wolnego portu. Te opcje można jednak w razie potrzeby zmienić.

mongoose.connect(uri, { useNewUrlParser: true, useUnifiedTopology: true });

Na końcu łączymy się z naszą bazą przy użyciu znanej Ci już funkcji mongoose.connect, a jako adres przekazujemy oczywiście fakeDB. Paczka mongodb-memory-server oferuje również funkcję do zakończenia działania naszej bazy, ale nie musimy tego robić. Gdy proces testowania się zakończy, baza i tak usunie się automatycznie.

Dlaczego całość jest schowana w hooku before? Dlatego, że chcemy, aby testy uruchomiły się dopiero wtedy, kiedy mamy pewność, że połączenie jest gotowe.

Uwaga!

Tryb --watch w pakiecie Mocha niezbyt dobrze radzi sobie z obsługą testowej bazy danych. Zresztą, jego wady ujawniły się już wcześniej, gdy mieliśmy problem z duplikowaniem modelu. Bierze się to stąd, że --watch uruchamia testy od nowa, ale tak naprawdę nie kończy całego procesu. Możemy to jednak obejść, sami tworząc task do obserwacji plików z testami.

Idea jest dość prosta. Wykorzystamy tutaj znaną Ci już paczkę onChange. Stworzymy dwa taski – jeden uruchomi Mochę tylko raz, a drugi, za pomocą onChange, będzie obserwował pliki z testami i kiedy wykryje jakąś zmianę, uruchomi task pierwszy.

Będzie to wyglądało następująco:

"test": "mocha \"./{,!(node_modules)/**/}*.test.js\"",
"test:watch": "onchange \"./**/*.js\" -i -k -- yarn test"

test służy do jednorazowego uruchomienia testów, a test:watch uruchamia je raz na początku (dba o to flaga i/--initial) oraz za każdym razem, kiedy wykryje zmianę w jakimkolwiek pliku z testami. Flaga -k (--kill) zapewnia nas, że po zakończeniu testów każdorazowo ten proces jest "zabijany".

Oczywiście, żeby test:watch mógł działać, musisz pobrać do swojego projektu paczkę onChange.

yarn add onchange@6.1.0

Od tej chwili Mocha nie powinna sprawiać nam przy pracy z Mongoose żadnych problemów. Do tego, możesz usunąć ze swoich testów bloki after, które zajmowały się zerowaniem modeli. Nie będziemy już ich potrzebować.

Read

Zaczniemy od przetestowania metod do pobierania danych, sprawdzimy więc find oraz findOne.

W tym celu stworzymy wewnątrz kolejny blok describe, co pomoże rozdzielić poszczególne grupy testów od siebie. Chcemy, aby testy do wybierania danych były w jednym miejscu, a do usuwania w innym.

Od razu przygotujemy też blok it.

describe('Reading data', () => {

  it('should return all the data with "find" method', () => {

  });

});

Zaczniemy od najprostszego testu. Sprawdzimy, czy find bez żadnych parametrów, zwróci nam po prostu wszystkie dane.

By to zweryfikować, potrzebujemy jakichś testowych danych. Bez nich nie będziemy wiedzieć, czy otrzymaliśmy pustą tablicę, bo kolekcja naprawdę była pusta, czy może po prostu natrafiliśmy na jakiś błąd.

Co istotne, nie tylko pierwszy test z tego bloku musi mieć dostęp do jakichś danych, podobnie będzie w przypadku np. findOne. Tym samym, zamiast organizować je tylko wewnątrz pierwszego it, kod, który się tym zajmie, będziemy przechowywać w bloku before całej grupy testów Reading data.

describe('Reading data', () => {

  before(async () => {
    const testDepOne = new Department({ name: 'Department #1' });
    await testDepOne.save();

    const testDepTwo = new Department({ name: 'Department #2' });
    await testDepTwo.save();
  });

  it('should return all the data with "find" method', () => {

  });

});

Dzięki temu mamy gwarancję, że zanim którykolwiek test z tej grupy zostanie uruchomiony, dane będą już dostępne. Oczywiście celowo wprowadziliśmy poprawne informacje – zakładamy, że nie spowodują błędu, bo sprawdzaniem takich przypadków zajmowaliśmy się już wcześniej.

Przejdźmy teraz do samego testu. Jak najłatwiej możemy sprawdzić, czy wszystko gra? Wystarczy, że spróbujemy pobrać dane i ustalimy, czy ich liczba to dwa. Nie musimy weryfikować samych danych ani sprawdzać, czy otrzymaliśmy dokument z dobrymi atrybutami – to również testowaliśmy w poprzednim submodule.

it('should return all the data with "find" method', async () => {
  const departments = await Department.find();
  const expectedLength = 2;
  expect(departments.length).to.be.equal(expectedLength);
});

Przyznaj, że kod jest dość logiczny. Szukamy wszystkich danych, a potem sprawdzamy, czy ich ilość jest zgodna z założeniami. Wiemy, że nasza baza ma dwa dokumenty w tej kolekcji, więc ta liczba powinna się zgadzać.

Warto zweryfikować również pobieranie jednego elementu. Atrybut _id jest w naszej bazie nadawany automatycznie, więc raczej nie ma sensu sprawdzanie, czy da się znaleźć coś właśnie po nim. Możemy za to przetestować name.

it('should return a proper document by "name" with "findOne" method', async () => {
  const department = await Department.findOne({ name: 'Department #1' });
  const expectedName = 'Department #1';
  expect(department.name).to.be.equal('Department #1');
});

Ponownie kod nie jest zbyt skomplikowany. Szukamy w kolekcji jednego z naszych dokumentów, a następnie sprawdzając atrybut name ustalamy, czy otrzymaliśmy prawidłowy.

Analogicznie jesteśmy w stanie testować kolejne atrybuty, choć akurat Department ma tylko jeden. Najczęściej kolekcje mają ich więcej, moglibyśmy zatem sprawdzić od razu wszystkie atrybuty w jednym teście.

Na końcu warto zrobić jeszcze jedną rzecz. Nie chcemy, aby zmiany poczynione w bazie danych, były widoczne również w kolejnych testach. Wtedy bowiem, nawet zwykła zmiana ich kolejności, mogłaby powodować wadliwe działanie. Bezpieczniej będzie przygotowywać bazę osobno dla każdej grupy testów, dlatego też po sprawdzeniu Read warto wyzerować dane w kolekcji Document.

Dodaj więc do bloku Reading data następujący hook after:

after(async () => {
  await Department.deleteMany();
});

Czas sprawdzić, czy nasze testy działają poprawnie. Odpal task test lub test:watch i zobacz, jaki jest rezultat.

image

Create

Musimy przekonać się, czy możemy poprawnie dodawać nowe dane do kolekcji, zajmiemy się więc metodą save.

Ponownie będziemy weryfikować tylko to, czy w przypadku poprawnych danych, dokument zostanie zapisany do kolekcji. Nie musimy jeszcze raz sprawdzać sytuacji, w której podano błędne informacje.

Bierzmy się do pracy. Pod Reading data, utwórz nowy blok – Creating data, a wewnątrz it.

describe('Creating data', () => {

  it('should insert new document with "insertOne" method', async () => {

  });

});

Tym razem nie musimy wykorzystywać hooka before i przygotowywać w jakiś sposób bazy danych, bo fakt, że jest pusta, wcale nam nie przeszkadza.

Jak będzie wyglądał ten test? Podobnie do dwóch poprzednich przypadków – też jest intuicyjny. Postaramy się dodać nowy element, a potem po prostu sprawdzić, czy istnieje w naszej kolekcji.

it('should insert new document with "insertOne" method', async () => {
  const department = new Department({ name: 'Department #1' });
  await department.save();
  const savedDepartment = await Department.findOne({ name: 'Department #1' });
  expect(savedDepartment).to.not.be.null;
});

Czy wszystko jest tutaj dla Ciebie jasne? Dodajemy nowy element do kolekcji, następnie staramy się go odnaleźć, a na końcu sprawdzamy, czy się to udało. Jeśli szukanie się nie powiedzie, findOne zwróci null. Polecenie to.not.be.null sprawdza, czy dostaliśmy coś innego niż null, ponieważ będzie to oznaczało, że otrzymaliśmy dokument.

Całość możemy kodować prościej, wykorzystując isNew. Jeśli dokument nie był jeszcze zapisany w bazie danych, to jego atrybut isNew jest równy true, a gdy został już do niej poprawnie wprowadzony, wartość tego atrybutu zwróci false. Z tą wiedzą możemy zmodyfikować nasz test następująco:

it('should insert new document with "insertOne" method', async () => {
  const department = new Department({ name: 'Department #1' });
  await department.save();
  expect(department.isNew).to.be.false;
});

Następnie uruchom testy i sprawdź, czy udało się przejść wszystko pozytywnie. Jeśli podążasz zgodnie z instrukcjami, to efekt powinien być następujący:

image

Na końcu warto byłoby tylko zadbać o usunięcie tego testowego dokumentu. Możemy ponownie wykorzystać hook after:

after(async () => {
  await Department.deleteMany();
});

Update

Pozostały nam do zweryfikowania jeszcze dwie operacje CRUD – Update i Delete. Zaczniemy od tej pierwszej. Warto tu przetestować metody updateOne i updateMany, ale również ideę modyfikacji danych z użyciem save.

Zacznij od utworzenia nowego bloku describe i trzech it.

describe('Updating data', () => {

  it('should properly update one document with "updateOne" method', async () => {

  });

  it('should properly update one document with "save" method', async () => {

  });

  it('should properly update multiple documents with "updateMany" method', async () => {

  });

});

Zanim przejdziemy do samego sprawdzania, musimy jeszcze przygotować testowe dane. Przyda nam się do tego hook beforeEach. Dlaczego ten, a nie before? Dlatego, że każdy test będzie modyfikował dane, idealnie byłoby więc je przywracać do stanu początkowego przed każdym kolejnym testem.

beforeEach(async () => {
  const testDepOne = new Department({ name: 'Department #1' });
  await testDepOne.save();

  const testDepTwo = new Department({ name: 'Department #2' });
  await testDepTwo.save();
});

Od razu możemy dodać też hook afterEach, który po każdym teście będzie usuwał dane z kolekcji, aby można było je wprowadzić od nowa.

afterEach(async () => {
  await Department.deleteMany();
});

Czas zabrać się już za sam test:

it('should properly update one document with "updateOne" method', async () => {
  await Department.updateOne({ name: 'Department #1' }, { $set: { name: '=Department #1=' }});
  const updatedDepartment = await Department.findOne({ name: '=Department #1=' });
  expect(updatedDepartment).to.not.be.null;
});

Wykorzystujemy tutaj ideę bardzo podobną do tej, którą pokazywaliśmy już w pierwszej wersji testu dodawania dokumentu. Staramy się zaktualizować jeden z dokumentów, następnie sprawdzamy, czy rzeczywiście istnieje on teraz w kolekcji. Znowu bazujemy na tym, że findOne zwraca dokument (jeśli znajdzie pasujący) albo null (jeśli takiego nie ma).

Przejdźmy do drugiego testu. Mongoose, mimo tego, że zezwala na użycie updateOne, zaleca korzystanie z metody save do aktualizacji pojedynczego dokumentu. Musimy więc przetestować również taki scenariusz.

it('should properly update one document with "save" method', async () => {
  const department = await Department.findOne({ name: 'Department #1' });
  department.name = '=Department #1=';
  await department.save();

  const updatedDepartment = await Department.findOne({ name: '=Department #1=' });
  expect(updatedDepartment).to.not.be.null;
});

Test jest bardzo podobny do pierwszego. Z tą różnicą, że tym razem korzystamy w aktualizacji danych z metody save. Oczywiście nie zapominajmy, że tak naprawdę pod maską save też korzysta z metody updateOne.

Pozostał nam jeszcze trzeci test.

Tym razem postaramy się sprawdzić, czy metoda updateMany zadziała poprawnie. Jak będzie wyglądał ten test? Najpierw spróbujemy zmodyfikować dane za pomocą metody updateMany, a następnie pobierzemy zawartość kolekcji i sprawdzimy, czy faktycznie wartości zostały zaktualizowane.

Spróbuj wykonać ten test bez naszej pomocy.

it('should properly update multiple documents with "updateMany" method', async () => {
  await Department.updateMany({}, { $set: { name: 'Updated!' }});
  const departments = await Department.find();
  expect(departments[0].name).to.be.equal('Updated!');
  expect(departments[1].name).to.be.equal('Updated!');
});

Można zrobić to także sprytniej:

it('should properly update multiple documents with "updateMany" method', async () => {
  await Department.updateMany({}, { $set: { name: 'Updated!' }});
  const departments = await Department.find({ name: 'Updated!' });
  expect(departments.length).to.be.equal(2);
});

Jeśli wszystkie testy zostały napisane poprawnie, modele powinny przejść je bez problemu, oczywiście o ile dobrze działają.

image

Delete

Czas na ostatnie testy. Musimy jeszcze sprawdzić metody deleteOne i deleteMany oraz remove (która tak naprawdę jest skrótem od deleteOne, ale i tak musi być zweryfikowana).

Zacznij od przygotowania bloku describe oraz trzech it.

describe('Removing data', () => {

  it('should properly remove one document with "deleteOne" method', async () => {

  });

  it('should properly remove one document with "remove" method', async () => {

  });

  it('should properly remove multiple documents with "deleteMany" method', async () => {

  });

});

Zanim przejdziemy do samych testów, zastanówmy się, czy nie potrzebujemy jakichś danych. Żeby coś usunąć, musimy najpierw to utworzyć, przygotujmy więc odpowiedni blok beforeEach, który zapewni nas, że zawsze będą istniały odpowiednie testowe dane. Dodajmy również blok afterEach, odpowiedzialny za każdorazowe zerowanie kolekcji po skończonych testach.

beforeEach(async () => {
  const testDepOne = new Department({ name: 'Department #1' });
  await testDepOne.save();

  const testDepTwo = new Department({ name: 'Department #2' });
  await testDepTwo.save();
});

afterEach(async () => {
  await Department.deleteMany();
});

Następnie możemy zabrać się już za same testy. Zaczniemy od pierwszego. Spróbuj go wykonać bez naszej pomocy.

it('should properly remove one document with "deleteOne" method', async () => {
  await Department.deleteOne({ name: 'Department #1' });
  const removeDepartment = await Department.findOne({ name: 'Department #1' });
  expect(removeDepartment).to.be.null;
});

Usuwamy dokument, następnie staramy się go znaleźć, a na końcu sprawdzamy, czy findOne zgodnie z planem zwróciło null. Taka wartość sugeruje bowiem, że elementu nie udało się zlokalizować, a więc już nie istnieje.

Co do drugiego testu, nie wykorzystywaliśmy remove zbyt często w praktyce, dlatego też tym razem Ci pomożemy.

Plan jest prosty. Musimy znaleźć jeden dokument, następnie postaramy się go usunąć, wywołując na nim bezpośrednio metodę remove. Na końcu sprawdzimy, czy element stanie się po tej operacji nullem.

it('should properly remove one document with "remove" method', async () => {
  const department = await Department.findOne({ name: 'Department #1' });
  await department.remove();
  const removedDepartment = await Department.findOne({ name: 'Department #1' });
  expect(removedDepartment).to.be.null;
});

Pozostał nam jeszcze ostatni test. Spróbuj go wykonać bez naszej asysty. Musimy sprawdzić, czy metoda deleteMany poprawnie usunie więcej niż jeden dokument.

it('should properly remove multiple documents with "deleteMany" method', async () => {
  await Department.deleteMany();
  const departments = await Department.find();
  expect(departments.length).to.be.equal(0);
});

Usuwamy wszystkie dokumenty i sprawdzamy, czy kolekcja jest teraz pusta.

Jeśli wszystkie testy zostały napisane poprawnie, a baza danych działa bezbłędnie, to naszym oczom powinien ukazać się bardzo przyjemny widok:

image

Podsumowanie

Jak widzisz, nie było to aż takie trudne, a przynajmniej mamy pewność, że wszystkie operacje będą wykonywać się poprawnie.

Warto poruszyć jeszcze dwie kwestie, które mogą Cię zastanawiać. Po pierwsze, czy nie można połączyć testów schematów z testami metod? Jak najbardziej można. Wtedy byłyby to testy integracyjne i siłą rzeczy stałyby się trochę trudniejsze do napisania. Niemniej jednak, w swoich przyszłych aplikacjach, jak najbardziej możesz korzystać z takiego pomysłu.

Po drugie, czy możemy testować działanie metod od razu na prawdziwej bazie danych? Tak, jednak wtedy musimy pamiętać o tym, aby łączyć się z bazą testową, a nie produkcyjną. Krótko mówiąc, musimy mieć pewność, że jesteśmy w izolacji z bazą, z której korzystają użytkownicy. Inaczej musielibyśmy wziąć pod uwagę, że wszystkie dane są zmienne. W takiej sytuacji test próbujący dodać dokument o nazwie test mógłby przejść za pierwszym razem poprawnie, a za drugim już nie. Wystarczyłoby, że atrybut name jest unikalny, a akurat przed odpaleniem testu po raz drugi, ktoś by taki dokument dodał. Do tego, musielibyśmy poprawnie usuwać dane testowe oraz pilnować się, aby przypadkiem nie wpływać na pozostałe.

Przyjrzymy się tym zagadnieniom w kolejnym submodule.

Zadanie: testujemy kolejny model

Przed Tobą odrobina praktyki. Twoim zadaniem będzie napisanie testów operacji CRUD dla kolejnego modelu – Employee. Całość powinna być zbudowana analogicznie do testów Department, które mogą posłużyć jako wzór.

Aby ułatwić Ci pracę, poniżej przedstawiamy listę testów, które musisz zaimplementować. Możesz jednak dodać również kolejne, jeśli uznasz to za stosowne. Opisy są po angielsku, by wdrażać Cię w nomenklaturę:

Reading data

  • should return all the data with "find" method,
  • should return proper document by various params with "findOne" method.

Creating data

  • should insert new document with "insertOne" method.

Updating data

  • should properly update one document with "updateOne" method,
  • should properly update one document with "save" method,
  • should properly update multiple documents with "updateMany" method.

Removing data

  • should properly remove one document with "deleteOne" method,
  • should properly remove one document with "remove" method,
  • should properly remove multiple documents with "deleteMany" method.

Dla ambitnych

Jeśli czujesz, że to dla Ciebie za mało, spróbuj przetestować również taką operację: Employee.find().populate('department'). Test powinien sprawdzić, czy takie wywołanie zwróci dokumenty, w których atrybut department nie będzie tylko referencyjnym id, lecz zwykłym obiektem, otrzymanym oczywiście z kolekcji departments.

To trochę trudniejsze zadanie, ale wbrew pozorom, końcowy kod wcale nie musi być długi. Możesz oczywiście wspomagać się internetem, ale najpierw spróbuj zastanowić się nad problemem bez pomocy.

30.4. Testowanie endpointów

Wiemy już, jak możemy przetestować schematy struktur danych oraz jak sprawdzić działanie metod modeli. Czy to jednak wystarczy? Nawet w przypadku prostych aplikacji backendowych, jak nasz serwer API, jest jeszcze jedna rzecz, którą koniecznie musimy zweryfikować. Mowa o działaniu endpointów.

Należy upewnić się, czy np. pod /departments serwer zwróci tablicę wszystkich działów, a /departments/1 pokaże jeden właściwy. Robiliśmy już takie testy, tyle że ręcznie za pomocą Postmana.

Było to bardzo proste, bo same requesty mogliśmy zapisywać i wywoływać później za pomocą jednego kliknięcia. Niemniej jednak zajmowało to trochę czasu i raczej nie chcielibyśmy powtarzać tego procesu za każdym razem, gdy zmodyfikujemy naszą aplikację.

Na szczęście ponownie na pomoc przyjdą nam testy automatyczne. Tak naprawdę będą robiły to samo, co my za pomocą Postmana, czyli symulowały requesty z odpowiednimi parametrami i sprawdzały rezultat. Jedyna różnica jest taka, że wcześniej wykonywaliśmy testy ręcznie, a teraz to zadanie przejmie Mocha.

Lokalizacja testów

Ponownie zastanówmy się, gdzie będziemy przechowywać nasze nowe testy. Wydaje się, że logiczne byłoby podążanie za trendem z poprzednich submodułów. Skoro testy schematów modeli danych, jak i ich metod, trzymaliśmy obok samych modeli, to może testy endpointów też powinny się tam znaleźć? Niekoniecznie.

Zauważ, że endpointy nie zawsze wykorzystują tylko dane z jednej kolekcji. U nas oczywiście często tak to wygląda, ale nie jest to regułą. W pewnych sytuacjach korzystamy z dwóch kolekcji. W takim przypadku, gdzie umieścilibyśmy te testy?

Co więcej, mogą istnieć też takie endpointy, które w ogóle nie będą korzystały z bazy danych. Pamiętasz, że przed wprowadzeniem MongoDB nasze endpointy wcale nie używały bazy danych?

Najlepszym wyjściem będzie więc potraktowanie ich jako coś bardziej "globalnego". Dlatego też nasza propozycja jest taka, aby tym razem przechowywać je w głównym katalogu serwera.

Przygotowujemy katalogi

Zacznij od założenia katalogu test, a w nim utwórz kolejny o nazwie api. To tutaj będziemy przechowywać nasze testy.

Moglibyśmy wszystkie testy endpointów dla danej grupy umieścić w jednym pliku. W takim scenariuszu nasz katalog test.api wyglądałby następująco:

/test
  /api
    departments.test.js
    employees.test.js
    products.test.js

Jest to dobra strategia, ale zauważ, że naszych endpointów jest całkiem sporo. Jeśli zechcemy testować dla każdego z nich dobry, ale też i zły scenariusz, to ich liczba znacznie wzrośnie.

Może lepiej podejść do tego nieco inaczej, zwiększyć czytelność, a przy tym zmniejszyć rozmiar pojedynczych plików. Nasza propozycja jest taka, aby wydzielić endpointy dla każdej grupy route'ów ze względu na ich metodę.

Mogłoby to wyglądać tak:

/test
  /api
    /departments
      delete.test.js
      get.test.js
      post.test.js
      put.test.js
    /employees
      delete.test.js
      get.test.js
      post.test.js
      put.test.js
    /products
      delete.test.js
      get.test.js
      post.test.js
      put.test.js

W tym submodule zajmiemy się wyłącznie testami grupy endpointów departments. Utwórz więc odpowiedni folder i pliki tylko dla niej.

Nasz katalog test na końcu powinien wyglądać więc tak:

/test
  /api
    /departments
      delete.test.js
      get.test.js
      post.test.js
      put.test.js

GET

Zaczniemy od get.test.js i sprawdzenia endpointów do pobierania danych.

Wejdź teraz do tego pliku. Standardowo rozpoczniemy od przygotowania pierwszego bloku describe i zaimportowania funkcji expect.

const expect = require('chai').expect;

describe('GET /api/departments', () => {

});

Oprócz expect będziemy tym razem potrzebować jeszcze jednej funkcji pomocniczej – request. Służy ona do symulowania requestów i niestety nie jest wbudowana w pakiet Chai. Zamiast tego twórcy przygotowali ją w formie pluginu chai-http.

Pobierz go więc teraz:

yarn add chai-http@4.3.0

Następnie musimy go jeszcze zaimportować do naszego pliku z testami, a na końcu dodać do chai jako middleware. Całość po zmianach powinna wyglądać tak:

const chai = require('chai');
const chaiHttp = require('chai-http');

chai.use(chaiHttp);

const expect = chai.expect;
const request = chai.request;

describe('GET /api/departments', () => {

});

Importujemy paczkę chai oraz chai-http. Następnie wprowadzamy ten plugin jako middleware (chai.use). Na końcu, dla wygody, tworzymy sobie skróty do metod expect i request. Zawsze lepiej pisać expect niż chai.expect.

Od teraz wciąż będziemy mieli dostęp do metody expect, ale dodatkowo możemy korzystać również z request.

Na końcu przygotuj jeszcze bloki it dla pojedynczych testów:

describe('GET /api/departments', () => {

  it('/ should return all departments', () => {

  });

  it('/:id should return one department by :id ', () => {

  });

  it('/random should return one random department', () => {

  });

});

Oczywiście dobrym pomysłem byłoby również przetestowanie tego, co serwer zwraca w przypadku błędów. To jednak wymagałoby od nas symulowania niepoprawnego działania aplikacji. Nie będziemy się więc teraz tym zajmować. Zamiast tego sprawdzimy tylko, czy przy poprawnym linku, serwer zwróci to, co trzeba.

Przygotowujemy serwer

Zanim przejdziemy do pisania testów, musimy zrobić jedną rzecz. Przed sprawdzaniem endpointów w Postmanie, zawsze uruchamialiśmy serwer, bo tylko w taki sposób mogliśmy weryfikować, jak zareaguje on na dany endpoint. To samo musi robić więc Mocha.

Zacznij od przygotowania eksportu serwera z server.js, aby można było go zaimportować w naszych plikach z testami.

const server = app.listen('8000', () => {
  console.log('Server is running on port: 8000');
});

module.exports = server;

Następnie zaimportuj go w naszym pliku get.test.js:

const server = require('../../server.js');

Od tej chwili będziemy mieli do niego dostęp pod stałą server.

Co z bazą danych?

W poprzednim submodule symulowaliśmy bazę danych przy użyciu mongodb-memory-server, ale tym razem podejdziemy do tematu inaczej. Zastosowanie tej paczki wciąż byłoby wykonalne, ale mało praktyczne. Nasz serwer korzysta w końcu z prawdziwej bazy danych i to ją wykorzystuje przy endpointach. Aby zmienić tę sytuację na czas testów, musielibyśmy mocno ingerować w działanie samego serwera. Dlatego tym razem będziemy testować go wraz z prawdziwą bazą danych.

Nie ma w tym nic złego, o ile baza nie jest równocześnie wykorzystywana przez użytkowników. W takim przypadku wyniki mogłyby zostać zafałszowane.

Jest to warunek łatwy do spełnienia, gdy aplikacja nie jest jeszcze udostępniona użytkownikom, ale nie ma większego problemu także z tymi opublikowanymi. Wystarczy, że u siebie w projekcie korzystasz z bazy lokalnej, a aplikacja umieszczona np. na Heroku łączy się z jej zdalnym odpowiednikiem na MongoDB Atlas.

W takiej sytuacji nie boimy się, że użytkownicy w każdej chwili mogą zmienić coś w danych, które testujemy, bo mamy dwie oddzielne bazy. Są identyczne w swojej strukturze, obsługiwane przez identyczny kod, ale jednak inne. Dodatkowo nie musimy się obawiać, że przy testach przypadkiem popsujemy coś w danych użytkowników.

Nasz serwer w tej chwili korzysta już z bazy danych MongoDB Atlas, dlatego musimy go lekko zmodyfikować, aby w przypadku uruchamiania testów, nie korzystał z niej, lecz z bazy lokalnej. Możemy dokonać tego bardzo prosto, wystarczy dodać odpowiednią pętlę warunkową. Jeśli process.env.NODE_ENV === 'production', to serwer powinien łączyć się z bazą zdalną. Jeśli środowisko jest inne (developerskie lub testowe), powinien wykorzystać bazę lokalną.

const dbURI = process.env.NODE_ENV === 'production' ? 'url to remote db' : 'url to local db';
mongoose.connect(dbURI, { useNewUrlParser: true, useUnifiedTopology: true });
const db = mongoose.connection;

Dodatkowo, dla porządku, warto zmodyfikować również task uruchamiający testy. Należy przed uruchamianiem Mochy ustawić NODE_ENV na test.

Windows:

"test": "set NODE_ENV=production && mocha \"./{,!(node_modules)/**/}*.test.js\"",
"test:watch": "onchange \"./**/*.js\" -i -k -- yarn test",

Linux lub Mac:

"test": "NODE_ENV=production mocha \"./{,!(node_modules)/**/}*.test.js\"",
"test:watch": "onchange \"./**/*.js\" -i -k -- yarn test",

Pierwszy test

Przejdźmy do praktyki i zajmijmy się teraz pierwszym testem.

Skoro sprawdzamy requesty GET, warto byłoby mieć jakieś dane do zwracania. Aby upewnić się, że nasza baza nie jest pusta, możemy skorzystać z idei z poprzedniego submodułu, czyli dodawania danych w hooku before, a następnie kasowania ich po testach w after.

before(async () => {
  const testDepOne = new Department({ _id: '5d9f1140f10a81216cfd4408', name: 'Department #1' });
  await testDepOne.save();

  const testDepTwo = new Department({ _id: '5d9f1159f81ce8d1ef2bee48', name: 'Department #2' });
  await testDepTwo.save();
});

after(async () => {
  await Department.deleteMany();
});

Zauważ, że sami ustaliliśmy _id dla naszych dokumentów. Jeśli tego nie zrobimy, to MongoDB nada je automatycznie. Dlaczego więc zdecydowaliśmy się na taki ruch? Z bardzo prostego powodu. Za chwilę będziemy testować endpoint do pobierania pojedynczego dokumentu po id. Gdybyśmy pozwolili nadać dokumentom _id w sposób automatyczny, to zwyczajnie nie wiedzielibyśmy jaki identyfikator tak naprawdę otrzymały i jaki _id możemy przetestować. Musielibyśmy więc najpierw sprawdzić, jakie jest _id losowego dokumentu i dopiero wtedy bylibyśmy w stanie przeprowadzić dla niego test. Po co dokładać sobie pracy?

Oczywiście, aby ten kod zadziałał, nie możesz zapomnieć o zaimportowaniu właściwego modelu.

const Department = require('../../models/department.model');

Teraz mamy pewność, że nasza baza danych podczas testów będzie miała w kolekcji departments dokładnie dwa dokumenty.

Przejdźmy już do samego testu.

Metoda request, z której skorzystamy, jest dość intuicyjna.

request(server).get('/api/departments');

Jako pierwszy parametr musimy wskazać serwer, który chcemy testować. Następnie możemy wywołać odpowiednią metodę (get, post, put, delete) wraz z linkiem. Wystarczy to, aby zasymulować połączenie pod tym adresem. Dodatkowo funkcja request dostarcza jeszcze kolejne metody, a niektóre z nich będą naprawdę przydatne. Na przykład send pozwoli na wysłanie wraz z requestem pakietu danych, co ma zastosowanie np. przy testach requestów POST.

request(server).post('/api/departments').send({ name: 'Department #1' });

Jak będzie wyglądał już sam test?

it('/ should return all departments', async () => {

  const res = await request(server).get('/api/departments');
  expect(res.status).to.be.equal(200);
  expect(res.body).to.be.an('array');
  expect(res.body.length).to.be.equal(2);

});

Przyznaj, że dość intuicyjnie. Łączymy się z api/departments za pomocą metody GET. Następnie sprawdzamy, czy serwer zwrócił kod sukcesu (a powinien) i czy otrzymaliśmy jako odpowiedź tablicę z dokładnie dwoma elementami. Wiemy, że ta ilość ma być właśnie taka, bo sami przed chwilą zadbaliśmy o odpowiednią zawartość kolekcji departments.

Tak jak w poprzednim submodule, nie sprawdzamy już poprawności struktury otrzymanych dokumentów.

A jak będzie wyglądał kolejny test?

it('/:id should return one department by :id ', () => {

});

Spróbuj wykonać go już bez naszej pomocy.

it('/:id should return one department by :id ', async () => {
  const res = await request(server).get('/api/departments/5d9f1140f10a81216cfd4408');
  expect(res.status).to.be.equal(200);
  expect(res.body).to.be.an('object');
  expect(res.body).to.not.be.null;
});

Łączymy się z endpointem, który powinien zwrócić nam pojedynczy obiekt. Wiemy, że musi to zrobić, bo :id jest prawidłowe i istnieje jeden dokument właśnie o takim identyfikatorze. Następnie sprawdzamy, czy otrzymaliśmy kod odpowiedzi sugerujący sukces (200), czy odpowiedź jest obiektem, oraz czy nie jest nullem. W tej sytuacji null sugerowałby bowiem, że obiektu nie udało się odnaleźć.

Analogicznie będziemy mogli zbudować trzeci test. Ponownie, spróbuj go wykonać bez naszej pomocy.

it('/random should return one random department', async () => {
  const res = await request(server).get('/api/departments/random');
  expect(res.status).to.be.equal(200);
  expect(res.body).to.be.an('object');
  expect(res.body).to.not.be.null;
});

Jeśli wszystkie testy działają właściwie, a nasze endpointy są poprawnie napisane, konsola powinna zwracać bardzo przyjemny widok.

image

Zbędne komunikaty

Zapewne udało Ci się zauważyć, że po tym, jak zaczęliśmy korzystać z naszego serwera, coś złego stało się z listą testów. Obok opisów (jak Department czy Reading data) pojawiły się jeszcze komunikaty takie jak Server is running on port... czy Connected to database. Mogą być one dość denerwujące i psują czytelność wyników testów.

Rozwiązać ten problem możemy na kilka sposobów. Najbardziej eleganckie byłoby po prostu nadpisanie funkcji console.log tak, aby faktycznie zwracała dane tylko wtedy, kiedy zmienna środowiskowa env wskazywałaby na false. W takiej sytuacji wystarczyłoby uruchamiać task z testami wraz z ustawieniem tej zmiennej na true, aby mieć pewność, że logi się nie pojawią. Innym sposobem będzie po prostu usunięcie ich wywołania w pliku server.js. Jest to najłatwiejsza opcja, o ile uważasz, że te komunikaty nie są Ci do niczego potrzebne.

POST

Czas na kolejne endpointy, a więc plik post.test.js.

Początek będzie bardzo podobny. Musimy przygotować odpowiednie importy oraz bloki describe i it.

const chai = require('chai');
const chaiHttp = require('chai-http');
const server = require('../../server.js');
const Department = require('../../models/department.model');

chai.use(chaiHttp);

const expect = chai.expect;
const request = chai.request;

describe('POST /api/departments', () => {

  it('/ should insert new document to db and return success', () => {

  });

});

Ponownie będziemy korzystać z chai.request do wykonywania requestów. Zadanie jest o tyle łatwiejsze, że tym razem mamy do napisania tylko jeden test – w końcu istnieje u nas tylko jeden endpoint dla metody POST, czyli ten dodający nowy dział.

Zastanówmy się, czy nie musimy w jakiś sposób przygotować naszej bazy danych, jak to robiliśmy we wcześniejszych testach.

Tym razem sprawdzamy dodawanie dokumentów, a więc to, czy w bazie aktualnie są dwa wpisy, sto czy zero, kompletnie nas nie interesuje. Dobrą praktyką byłoby za to usuwanie naszego próbnego wpisu, który będzie dodawany podczas wysyłania testowego requestu. Nie będziemy więc tym razem korzystać z hooka before, ale after ponownie nam się przyda.

after(async () => {
  await Department.deleteMany();
});

Teraz przejdźmy już do samego testu.

it('/ should insert new document to db and return success', async () => {
  const res = await request(server).post('/api/departments').send({ name: '#Department #1' });
  const newDepartment = await Department.findOne({ name: '#Department #1' });
  expect(res.status).to.be.equal(200);
  expect(res.body.message).to.be.equal('OK');
  expect(newDepartment).to.not.be.null;
});

Początek jest bardzo podobny do wcześniejszych testów – wysyłamy request pod wybrany adres przy użyciu konkretnej metody. Różnica jest tylko taka, że tym razem wraz z requestem wysyłane są również dane (body). Pamiętamy bowiem, że nasz endpoint /api/departments POST oczekuje na nazwę działu.

const res = await request(server).post('/api/departments').send({ name: '#Department #1' });

Całkiem jasne mogą być dwa warunki:

expect(res.status).to.be.equal(200);
expect(res.body).to.be({ message: 'OK' });

Oczekujemy, że serwer zwróci kod sukcesu, a jego odpowiedź to { message: 'OK' }. Pewnie pamiętasz, że sami ustaliliśmy treść tego komunikatu, a teraz sprawdzamy, czy to wymaganie jest spełnione.

Jak widzisz jednak to nie wszystko. Oprócz tego mamy jeszcze kod, który sprawdza, czy element rzeczywiście jest dodany do bazy.

const newDepartment = await Department.findOne({ name: '#Department #1' });
...
expect(newDepartment).to.not.be.null;

Pewnie domyślasz się skąd taki pomysł. Fakt, że serwer zwróci kod odpowiedzi 200, to trochę za mało, aby dać nam pewność, że wszystko działa dobrze. Na dobrą sprawę, jeśli skrypt będzie napisany niepoprawnie, jest spora szansa, że taką odpowiedź dostaniemy nawet przy błędzie, niezależnie od tego, czy do bazy udało się coś wprowadzić, czy też nie. Nie możemy liczyć na to, że programista jest nieomylny, ani traktować kodu odpowiedzi 200 jako gwarancji.

Zauważ, że w poprzednich testach również nie poprzestawaliśmy na sprawdzeniu samej odpowiedzi. Zamiast tego badaliśmy, czy otrzymaliśmy odpowiedni typ danych (np. tablica) oraz ich ilość (np. 2). Dopiero to dawało pewność, że endpoint poprawnie łączy się z bazą i zwraca to, co trzeba.

Teraz mamy trochę trudniejszy scenariusz, bo serwer zwraca tylko komunikat { message: 'OK' }. Dlatego też zwyczajnie musimy sprawdzić, czy po requeście w bazie danych pojawi się nowy dokument.

Jeśli Twój serwer działa poprawnie, to test powinien zaświecić się na zielono.

PUT

Czas na endpoint do edycji dokumentów. Ponownie będziemy musieli napisać tylko jeden test.

Zacznij standardowo, od przygotowania startowej struktury.

const chai = require('chai');
const chaiHttp = require('chai-http');
const server = require('../../server.js');
const Department = require('../../models/department.model');

chai.use(chaiHttp);

const expect = chai.expect;
const request = chai.request;

describe('PUT /api/departments', () => {

  it('/:id should update chosen document and return success', () => {

  });

});

Następnie zastanówmy się nad przygotowaniem bazy. Tym razem konieczne będą testowe dane, ale wystarczy nam tylko jeden dokument.

before(async () => {
  const testDepOne = new Department({ _id: '5d9f1140f10a81216cfd4408', name: 'Department #1' });
  await testDepOne.save();
});

after(async () => {
  await Department.deleteMany();
});

Sam test spróbuj napisać już bez naszej pomocy. Dasz radę? ;)

it('/:id should update chosen document and return success', async () => {
  const res = await request(server).put('/api/departments/5d9f1140f10a81216cfd4408').send({ name: '=#Department #1=' });
  expect(res.status).to.be.equal(200);
  expect(res.body).to.not.be.null;
});

Ten test był trochę prostszy. Nasz endpoint do edycji danych jest skonstruowany w taki sposób, że zwraca zmieniony dokument, a nie tylko komunikat w stylu { message: 'OK' }. Tym samym możemy pozostać przy sprawdzeniu, czy odpowiedź (res.body) nie równa się null.

Jeśli jednak uważasz, że to za mało i chcesz upewnić się, czy naprawdę w bazie danych doszło do zmiany, możesz jeszcze ten test przebudować i sprawdzić, czy dokument faktycznie się zmienił.

it('/:id should update chosen document and return success', async () => {
  const res = await request(server).put('/api/departments/5d9f1140f10a81216cfd4408').send({ name: '=#Department #1=' });
  const updatedDepartment = await Department.findOne({ _id: '5d9f1140f10a81216cfd4408' });
  expect(res.status).to.be.equal(200);
  expect(res.body).to.not.be.null;
  expect(updatedDepartment.name).to.be.equal('=#Department #1=');
});

Jeśli serwer działa dobrze, task test w konsoli powinien zwrócić nam same zielone sukcesy.

Podsumowanie

Musisz przyznać, że przy użyciu metody request, pisanie testów dla endpointów jest niezwykle intuicyjne i stosunkowo proste.

Jeśli tylko przypomnimy sobie wszystkie te chwile, kiedy zaglądaliśmy do Postmana w celu sprawdzenia, czy czasem "nic się nie popsuło" po zmianach na naszym serwerze, to łatwo uświadomić sobie jaką wartość mają testy automatyczne.

Zadanie: ostatni test

Jak zapewne udało Ci się zauważyć, w submodule zabrakło jeszcze jednego testu. Chodzi oczywiście o DELETE w endpoincie /departments/:id. Jego wykonanie będzie Twoim zadaniem.

Postaraj się napisać ten test analogicznie do pozostałych w submodule. Nie zapomnij też o tym, że każda metoda ma u nas odpowiadający plik, a więc test tego endpointu powinien być przechowywany w delete.test.js.

30.5. Testy w praktyce

Dotychczas zajmowaliśmy się głównie testowaniem gotowych skryptów. Mamy nadzieję, że tęsknisz już za właściwym kodowaniem i nie możesz doczekać się na kolejną dawkę praktyki.

Popracujemy nad rozwojem naszej aplikacji festiwalowej. Poniżej przedstawimy Ci trzy propozycje nowych funkcjonalności do wykonania. Twoim zdaniem będzie wybranie przynajmniej jednej z nich i poprawne jej zaimplementowanie. Pamiętaj przy tym, aby korzystać również z testów. Pozwolą Ci one upewnić się, czy to, co wykonano, faktycznie działa.

Nie narzucamy żadnej metodologii co do momentu, w którym należy pisać testy. Możesz robić to już po stworzeniu funkcjonalności, ale również pójść w kierunku TDD. Decyzja należy do Ciebie.

Opcja 1 – więcej koncertów

Twórcy festiwalu są bardzo zadowoleni z działania strony, jednak już teraz myślą nad jej rozwojem. W tym roku zaproszono tylko trzy gwiazdy, ale w następnym ma być ich znacznie więcej. Klient twierdzi, że zamiast długich występów tylko trzech artystów, chciałby postawić na koncerty grupowe, gdzie każdy miałby na swój występ około 15 minut. Tym samym, ilość "pojedynczych" koncertów ma być według niego nie mniejsza niż 30, z tego też powodu, marzy mu się wyszukiwarka.

Co ważne, nie jest ona potrzebna już teraz. Na razie wystarczy przygotowanie pomocniczych endpointów na serwerze, aby w przyszłości, w razie potrzeby, implementacja wyszukiwarki mogła przebiec szybciej.

Po rozmowie z klientem udało się ustalić następujące endpointy:

  • /concerts/performer/:performer – do wyszukiwania koncertów danego artysty,
  • /concerts/genre/:genre - do wyszukiwania koncertów z wybranego gatunku muzycznego,
  • /concerts/price/:price_min/:price_max – do wyszukiwania koncertów o cenie z przedziału :price_min - :price_max,
  • /concerts/price/day/:day – do wyszukiwania koncertów w wybranym dniu.

Wszystkie endpointy powinny zwracać przefiltrowane dane w formie tablicy. Dodatkowo, do każdego z nich musisz napisać poprawne testy.

Opcja 2 – ilość wolnych miejsc

Klient chciałby, aby na stronie głównej, przy każdym z koncertów, widniała informacja o liczbie wolnych miejsc.

Powinno to wyglądać mniej więcej tak:

image

Zadanie wymaga zmian zarówno po stronie frontendu, jak i backendu.

Serwer

Musisz zmodyfikować endpoint pokazujący koncerty, w taki sposób, aby oprócz podstawowych informacji, zwracany był również nowy atrybut – nazwijmy go tickets. Jego wartość powinna być równa liczbie wolnych miejsc na dany koncert.

Pamiętaj, aby napisać odpowiednie testy dla tego endpointu.

Wskazówki
  • Przyjmijmy, że liczba miejsc na każdym koncercie to 50. Tym samym, aby wyliczyć liczbę wolnych, wystarczy sprawdzić, ile biletów na dany koncert już sprzedano (kolekcja seats), a następnie odjąć ją od 50. Przykładowo, jeśli wiemy, że zarezerwowano 5 miejsc na koncert nr 1, to po odjęciu tej wartości od 50, wyjdzie nam, że mamy jeszcze 45 wolnych.
  • Pamiętaj, że find w Mongoose zwraca tablicę. Tym samym przed jej przesłaniem do klienta, możemy ją dowolnie zmodyfikować, na przykład dodając do każdego z jej elementów nowy atrybut.
  • Nic nie stoi na przeszkodzie, żeby w jednym endpoincie korzystać z dwóch modeli. Pamiętaj o tym.

Klient

Twoim zadaniem będzie takie zmodyfikowanie komponentu concert, aby wyświetlał nową informację. Ma to wyglądać identycznie jak na obrazku, a więc, jeśli na koncert jest jeszcze 20 wolnych miejsc, to ma się pojawić informacja Only 20 tickets left. Jeśli tych miejsc jest tylko 10, to Only 10 tickets left itd.

Do stylowania wykorzystaj poniższą regułę CSS:

.concert__info__tickets {
  opacity: 0.8;
  font-size: 0.9rem;
  position: absolute;
  bottom: 3rem;
  left: 1.5rem;
}

Opcja 3 – popraw podstrony "Prices"

W tej chwili treść podstrony "Prices" jest umieszczona na stałe w pliku PricesPage.js. Zleceniodawca chciałby, aby wszystkie informacje były przechowywane na serwerze, w bazie danych. Podstrona ta powinna je pobierać, a następnie wykorzystać do wyrenderowania właściwego tekstu. To dla klienta bardzo ważne, bowiem rozmawia już z nową firmą na temat wykonania panelu administracyjnego i chciałby przekazać odpowiednio przygotowaną stronę. Woli zlecić tę ostatnią poprawkę nam, bo wie, że lepiej orientujemy się w budowie serwera.

Zadanie wymaga zmian zarówno po stronie klienta, jak i serwera.

Serwer

Twoje zadanie będzie kilkuetapowe.

Etap 1

Zacznij od utworzenia nowej kolekcji workshops, która będzie przechowywać dane zapisane w tej chwili po stronie klienta.

Jej struktura powinna być następująca.

Workshops

  • _id – identyfikator,
  • name – nazwa warsztatu,
  • concertId – id koncertu, którego tyczy się ten warsztat.

Jako _id koncertu należy podać ten koncert, który odbywa się w dniu tego warsztatu, czyli przykładowo dla każdego z warsztatów z dnia pierwszego należy przypisać koncert Johna Doe.

Uwaga – nie zapomnij o napisaniu testów do nowego modelu.

Etap 2

Następnie wypełnij nową kolekcję danymi, zgodnie z tym, co aktualnie przedstawia strona "Prices".

Etap 3

Upewnij się co do poprawności danych w kolekcji concerts, tak aby price danego koncertu zgadzało się z ceną podaną na podstronie PricesPage. Czyli np. koncert Johna Doe powinien mieć przypisaną cenę 25$.

Etap 4

Zmodyfikuj endpoint /concerts, tak aby każdy obiekt był zwracany z jeszcze jednym atrybutem – workshops. Powinny być w nim zawarte te dokumenty z kolekcji workshops, których atrybut concertId zgadza się z id samego koncertu. Możesz przy tym ponownie zmodyfikować schemat modelu Concert. Warto zastanowić się też nad wykorzystaniem idei referencji (ref).

Klient

Musisz tak zmodyfikować podstronę PricesPage.js, żeby wciąż pokazywała dokładnie taką samą treść jak teraz, ale tym razem bazowała na informacjach, które można pobrać pod endpointem /concerts.

Pozostawiamy Ci tutaj dowolność wykonania. Staraj się jednak korzystać z takich samych pomysłów, jakie są już zastosowane, aby zachować ogólną spójność. Koniecznie sprawdź, jak wykonywane były wcześniejsze podstrony, w jaki sposób zbudowany jest reducer oraz jak komunikują się z nim komponenty. To powinno dać Ci ogólny pogląd na ich działanie.

Zadanie: gotowe!

Gotowe zadanie wyślij Mentorowi do sprawdzenia.

30.6. Podsumowanie

Testy w backendzie okazały się niewiele trudniejsze od tych, które wykonywaliśmy już po stronie klienta. Mogliśmy też odczuć, że było ich mniej, a to dlatego, że w naszych aplikacjach zawsze to frontend pełnił bardziej istotną funkcję. Pamiętasz zapewne, jak dużo rzeczy musieliśmy sprawdzać w aplikacjach reactowych.

Jedyną wadą testów backendowych była potrzeba użycia innego task runnera. Niemniej jednak Mocha nie różni się zbytnio od Jesta, a wiemy też, że jeśli się uprzemy, istnieje możliwość użycia po stronie serwera również tego drugiego narzędzia.

Podsumowując temat, nie sposób przecenić roli testów. Są bardzo intuicyjne i proste w użyciu, a dają nam przy tym pewność co do poprawnego działania serwera. Warto więc pamiętać o nich w każdym większym projekcie i nie traktować jako zło konieczne, ale raczej bardzo ważnego sprzymierzeńca.

Na pewno jeszcze nie raz docenisz ich obecność.

30.7. Quiz powtórkowy

Na koniec przygotowaliśmy dla Ciebie quiz powtórkowy. Pomoże Ci on utrwalić wiedzę z poprzednich modułów.

Odpowiedzi nie są nigdzie zapisywane, więc pozostaną tylko do Twojej wiadomości. Ten quiz ma Ci posłużyć jako pomoc w nauce – dlatego pod każdym pytaniem znajdziesz guzik, który sprawdzi poprawność Twoich odpowiedzi oraz poda Ci wyjaśnienie poruszanego zagadnienia.

1. Jaka jest przewaga baz danych nad zwykłymi tablicami?

Wyjaśnienie

W wielu przypadkach użycie bazy danych jest lepszym rozwiązaniem niż zastosowanie zwykłej tablicy. Jak już wiesz z poprzedniego modułu – informacje w bazie danych utrzymywane są na zewnętrznych serwerach, zatem nie są zależne od naszej aplikacji, a co za tym idzie – nie znikają, gdy wyłączymy komputer.

Jedną z głównych zalet baz jest możliwość narzucenia z góry, jakie dane będą przyjmowane, oraz zastosowanie relacyjności, czyli powiązań między poszczególnymi kolekcjami.

2. Co oznacza skrót CRUD?

Wyjaśnienie

CRUD to oczywiście "Create, Read, Update, Delete". Jest to zestaw poleceń do modyfikacji danych w bazach – ich tworzenia, odczytywania, aktualizacji i usuwania. Każdy silnik baz danych oferuje je w mniej lub bardziej zaawansowanej formie.

3. Załóżmy, że w bazie danych MongoDB, w przykładowej kolekcji persons, szukamy osób w odpowiednim wieku za pomocą komendy: db.persons.find({ age: { $and: [{ $gt: 18 }, { $lt: 40 }] } });.

Wyjaśnienie

MongoDB daje nam szeroki wachlarz operatorów, których możemy użyć do przeszukiwania baz danych. Jednymi z bardziej przydatnych są operatory koniunkcji $and i alternatywy $or, które są odpowiednikami znanych z JS-a && i ||. Dodatkowo użyte tu $gt to "więcej niż", a $lt – "mniej niż". Zatem nasza komenda wyszuka osoby w wieku większym niż 18 i mniejszym niż 40, a za pomocą zwykłej pętli możemy to zapisać jako if(age > 18 && age < 40).

4. W Mongoose:

Wyjaśnienie

Choć MongoDB domyślnie nie blokuje nas żadnymi ograniczeniami podczas dodawania danych, sytuacja wygląda inaczej w Mongoose. Tutaj każda kolekcja musi mieć własny model ze schematem struktury danych. Pozwala to na utrzymanie ich spójności.

Mongoose sprawdza dane wprowadzane do bazy pod kątem ilości i nazw atrybutów, ale walidowane są też same wartości – czy ich typ jest zgodny z tym, co założyliśmy.

5. Pomówmy o relacyjności danych w bazach, czyli o ich wzajemnych powiązaniach i zależnościach. Wiemy, że:

Wyjaśnienie

Dane w bazach nie powinny się dublować, ponieważ znacznie utrudnia to ich utrzymanie i aktualizację. Konieczność zmiany tych samych informacji w kilku miejscach jest nieefektywna i może prowadzić do wielu pomyłek.

Tworzenie relacji między danymi za pomocą referencji to najprostszy sposób na ich powiązanie (np. dane na temat tytułów książek mają odwołanie do informacji o autorze zgromadzonych w innej kolekcji). Referencją może być nazwa lub choćby unikalny id, do którego się odwołujemy.

Mongoose posiada wbudowaną metodę populate, która służy właśnie do łatwego pobierania danych z jednej kolekcji, od razu z informacjami, do których odnoszą się referencje.

;